前面已經示範了如何將我們設計好的 signal 機制,容入兩個主流框架下(React、Vue),這篇開始我們來回顧我們設計好的 signal 核心機制,探討哪些可以更好的地方。
把「多步、跨 await 的更新」合併成一次 effect 重跑,同時不影響我們既有的 lazy computed 與 microtask 排程設計。
本篇在不改你對外 API 的前提下,只擴充 scheduler.ts 與極少數接點。
set() 會被我們的 scheduler(Set + queueMicrotask)合併到同一個 microtask,effect 只重跑一次。await 不行:每個 await 都是新的微任務;沒有 Transaction,effect 會各跑一次。// ❌ 沒有 transaction:effect 會跑兩次
async function onClick() {
  a.set(1); // 安排第 1 輪 flush
  await fetch("/api");
  b.set(2); // 安排第 2 輪 flush
}
// ✅ 有 transaction:effect 只跑一次(交易結束才 flush)
async function onClick() {
  await transaction(async () => {
    a.set(1);
    await fetch("/api");
    b.set(2);
  });
}
scheduler.tsbatchDepth:讓 batch() 與 transaction() 可任意巢狀。scheduleJob() 在 batchDepth > 0 時只入佇列,不安排 microtask。flushJobs()。// scheduler.ts
export interface Schedulable { run(): void; disposed?: boolean }
const queue = new Set<Schedulable>();
let scheduled = false;
let batchDepth = 0;
export function scheduleJob(job: Schedulable) {
  if (job.disposed) return;
  queue.add(job);
  // 只有在「不在批次/交易中」才安排 microtask
  if (!scheduled && batchDepth === 0) {
    scheduled = true;
    queueMicrotask(flushJobs);
  }
}
// 與原本相同:同步區塊合併,結尾 flush 一次
export function batch<T>(fn: () => T): T {
  batchDepth++;
  try {
    return fn();
  } finally {
    batchDepth--;
    if (batchDepth === 0) flushJobs();
  }
}
// Promise 判斷
function isPromiseLike<T = unknown>(v: any): v is PromiseLike<T> {
  return v != null && typeof v.then === "function";
}
// 新增:支援 async 的交易;跨 await 合併,最外層結束時 flush 一次
export function transaction<T>(fn: () => T): T;
export function transaction<T>(fn: () => Promise<T>): Promise<T>;
export function transaction<T>(fn: () => T | Promise<T>): T | Promise<T> {
  batchDepth++;
  try {
    const out = fn();
    if (isPromiseLike<T>(out)) {
      // 非同步:等 fn 完成(成功/失敗)後再出站並視需要 flush
      return Promise.resolve(out).finally(() => {
        batchDepth--;
        if (batchDepth === 0) flushJobs();
      });
    }
    // 同步:直接出站並視需要 flush
    batchDepth--;
    if (batchDepth === 0) flushJobs();
    return out as T;
  } catch (e) {
    // 例外也要正確出站並做一次 flush
    batchDepth--;
    if (batchDepth === 0) flushJobs();
    throw e;
  }
}
export function flushSync() {
  if (!scheduled && queue.size === 0) return;
  flushJobs();
}
function flushJobs() {
  scheduled = false;
  let guard = 0;
  while (queue.size) {
    const list = Array.from(queue);
    queue.clear();
    for (const job of list) job.run();
    if (++guard > 10000) throw new Error("Infinite update loop");
  }
}
batch 用法完全不受影響;多了 transaction(async) 後,跨 await 的多次 set() 也會合併成一次 effect 重跑。signal.set() 照舊呼叫 effect.schedule();EffectInstance.schedule() 內是 scheduleJob(this)。computed 仍維持 lazy(只標髒標記,不進 scheduler)。set() 只把它標記為 stale,不因為 transaction 而提前重算。set() 都不會排 microtask,只有在最外層 transaction 結束(或 throw)後,一次 flushJobs()。batchDepth 支援巢狀交易,只有最外層退出時才 flush。fn throw 也會正確出棧並 flush(見上方 catch/finally 邏輯)。await 的多步更新 → transaction(async) 合併為一次重跑// Counter.tsx
import React from "react";
import { signal } from "../core/signal.js";
import { createEffect } from "../core/effect.js";
import { transaction } from "../core/scheduler.js";
import { useSignalValue } from "./react-adapter";
// 資料層(可與 React 無關)
const a = signal(0);
const b = signal(0);
// 用 createEffect 觀察重跑次數
createEffect(() => {
  // 一次重跑會同時讀到 a/b 的最新值
  console.log("effect run:", a.get(), b.get());
});
export function Counter() {
  const va = useSignalValue(a);
  const vb = useSignalValue(b);
  const onClick = async () => {
    await transaction(async () => {
      a.set(va + 1);
      await Promise.resolve(); // 模擬一個 await(例如 fetch)
      b.set(vb + 1);
    }); // ← 交易結束才 flush,一次重跑
  };
  return (
    <div>
      <p>a={va} / b={vb}</p>
      <button onClick={onClick}>+a, then await, then +b(一次重跑)</button>
    </div>
  );
}
startTransition)import { useEffect } from "react";
import { signal } from "../core/signal.js";
import { transaction } from "../core/scheduler.js";
import { useSignalValue, useSignalState } from "./react-adapter";
const titleSig = signal("Hello");
export function Editor() {
  const committed = useSignalValue(titleSig);
  const [draft, setDraft] = useSignalState(committed); // 本地 signal 草稿
  // 外部值變更時,同步草稿(可選)
  useEffect(() => setDraft(committed), [committed]);
  const save = async () => {
    await transaction(() => {
      titleSig.set(draft); // 提交時一次寫回全域 signal
      // 若此處還有大量 React setState,才考慮用 startTransition 包「那些 setState」
    });
  };
  return (
    <>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
      <button onClick={save}>Save</button>
      <p>committed: {committed}</p>
    </>
  );
}
提醒:
startTransition不會改變signal.set()的優先級;它只影響 React 自己的setState。多步資料提交合併 → 用transaction(async);UI 過渡 → 用useDeferredValue或複本 state/signal。
<script setup lang="ts">
import { signal } from "../core/signal.js";
import { transaction } from "../core/scheduler.js";
import { useSignalRef } from "./vue-adapter";
const a = signal(0);
const b = signal(0);
const va = useSignalRef(a); // Vue ref
const vb = useSignalRef(b);
async function run() {
  await transaction(async () => {
    a.set(va.value + 1);
    await Promise.resolve(); // 模擬 await
    b.set(vb.value + 1);
  }); // ← 一次 flush,一次重跑
}
</script>
<template>
  <p>a={{ va }} / b={{ vb }}</p>
  <button @click="run">+a, await, +b(一次重跑)</button>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { signal } from "../core/signal.js";
import { transaction } from "../core/scheduler.js";
import { useSignalRef } from "./vue-adapter";
const titleSig = signal("Hello");
const committed = useSignalRef(titleSig); // 讀外部值
const draft = ref(committed.value); // 本地草稿(Vue 自己的狀態)
watch(committed, v => (draft.value = v)); // 可選:外部變時同步複本
async function save() {
  await transaction(() => {
    titleSig.set(draft.value); // 提交時一次寫回
  });
}
</script>
<template>
  <input v-model="draft" />
  <button @click="save">Save</button>
  <p>committed: {{ committed }}</p>
</template>
提醒:Vue 的
<Transition>/動畫只影響顯示時機,不會延後資料寫入。資料提交合併 →transaction(async);若要讓重 UI 區域慢一點更新,可以在 UI 層做延遲或分區顯示。
await 的多步寫入」包進 transaction(async) → 一次 effect 重跑。signal.set() 的時機。await 合併的時間線
本篇把「跨 await 的多步更新」收斂為一次副作用重跑:
transaction(async) 與既有 batch 共用深度計數,只在最外層退出時 flushJobs()。computed 依舊 lazy;只標髒標記、不提前重算。下一篇,我們把「合併」提升為「原子性」:失敗就回到進入交易前的狀態,簡單來說:這一篇解決「一次跑」問題,下一篇解決「要嘛全成,要嘛不動」。